Версионирование API – важная и зачастую сложная задача, у которой скорее всего нет какого-то универсального решения. Рассказываю об одном из возможных подходов в приложениях на базе Laravel.
Впрочем, сразу оговорюсь – такой способ можно реализовать не только в Laravel-приложениях, а ещё он может подойти не всем. Внедрение описанного метода может потребовать большого количества рефакторинга, и в таком случае быстрее и проще будет пойти (возможно) по пути копипастинга.
Зачем версионировать API
Зачастую версионирование API требуется приложениям, когда наступают следующие события:
-
API является публичным;
-
У API есть какое-то количество потребителей, для которых крайне важна обратная совместимость;
-
В структуры входных и/или выходных данных нужно внести обратно несовместимые изменения.
Разумеется, это не единственные причины, когда может потребоваться версионирование, но по моему опыту эта необходимость появлялась именно в этом случае.
Как можно версионировать API
В большинстве статей по тематике версионирования чаще выделяется один способ – создавать копии контроллеров, запросов и обработчиков, и размещать их под префиксом новой версии (например, /v2
). В целом, способ из этой статьи схож, однако, подход немного другой – я предлагаю (когда это возможно) версионировать только запросы и трансформеры (то есть то, что генерирует ваш ответ API).
Принцип
Принцип достаточно прост на словах – обработчикам запросов к API не нужно знать о том, под какой версией они запускаются. Если они достаточно универсальны, чтобы обрабатывать запросы из разных версий, всю необходимую информацию им нужно предоставлять версионированными запросами.
Возьмём пример обработчика, который регистрирует покупателя в интернет-магазине. К нему приходит запрос с номером телефона, страной, именем и паролем покупателя. Обработчик должен создать запись, отправить SMS с кодом подтверждения регистрации и отдать успешный ответ на запрос.
<?php namespace App\Http\Controllers; use App\Jobs\SendRegistrationCode; use App\Models\Customer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class CustomerRegistrationApiController { public function store(Request $request): JsonResponse { $existedCustomer = Customer::where('phone', $request->input('phone_number'))->first(); if (!is_null($existedCustomer)) { throw new \InvalidArgumentException('Покупатель уже зарегистрирован!'); } $customer = Customer::create([ 'phone' => $request->input('phone_number'), 'phone_country' => $request->input('phone_country') ?? 'RU', 'password' => bcrypt($request->input('password')), 'name' => $request->input('name'), 'surname' => $request->input('surname'), ]); dispatch(new SendRegistrationCode($customer)); return response()->json([ 'created' => true, 'id' => $customer->id, ]); } }
Теперь предположим, что нам потребовалось переименовать некоторые поля. Вместо полей phone_number
и phone_country
мы хотим использовать объект phone
с полями phone.number
и phone.country
, вместо name
– first_name
, а вместо surname
– last_name
. В ответе вместо поля id
мы хотим отправлять только номер телефона.
Посмотрим, как это можно сделать разными способами.
Первый способ – проверка версии
Для начала можно собирать поля, проверяя текущую версию. Если версия не указана или v1
, используем старые поля, если версия равна v2
– берём данные из новых. Предположим, что номер версии передаётся в route-параметре.
<?php namespace App\Http\Controllers; use App\Jobs\SendRegistrationCode; use App\Models\Customer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class CustomerRegistrationApiController { public function store(Request $request, ?string $version = null): JsonResponse { $data = match (true) { // Версия не указана или равна первой – используем старые поля. is_null($version) || $version === 'v1' => [ 'phone' => $request->input('phone_number'), 'phone_country' => $request->input('phone_country') ?? 'RU', 'password' => bcrypt($request->input('password')), 'name' => $request->input('name'), 'surname' => $request->input('surname'), ], $version === 'v2' => [ 'phone' => $request->input('phone.number'), 'phone_country' => $request->input('phone.country') ?? 'RU', 'password' => bcrypt($request->input('password')), 'name' => $request->input('first_name'), 'surname' => $request->input('last_name'), ], default => throw new \InvalidArgumentException('Invalid data.'), }; $existedCustomer = Customer::where('phone', $data['phone'])->first(); if (!is_null($existedCustomer)) { throw new \InvalidArgumentException('Покупатель уже зарегистрирован!'); } $customer = Customer::create($data); dispatch(new SendRegistrationCode($customer)); $response = match (true) { is_null($version) || $version === 'v1' => [ 'created' => true, 'id' => $customer->id, ], $version === 'v2' => [ 'phone' => $customer->phone, ], }; return response()->json($response); } }
Технически это будет работать, и если у вас небольшой API, возможно, этого будет достаточно. Однако, чем больше контроллеров/обработчиков придётся так переписывать, тем сложнее их будет поддерживать и вводить новые версии.
К тому же, вы скорее всего будете использовать валидацию запроса. При таком подходе во все обязательные поля придётся добавлять правила вида required_without:
, чтобы убедиться, что хотя бы одно необходимое поле было передано (однако, это может пропустить запросы, где одни данные переданы в поле от версии v1
, а другие – в поле от версии v2
).
Второй способ – копия контроллера (и запроса)
Так же можно просто скопировать код контроллера в новый и использовать его для регистрации покупателей в версии v2
. Так мы избавимся от проверок и изолируем код конкретной версии в конкретном классе.
<?php namespace App\Http\Controllers; use App\Jobs\SendRegistrationCode; use App\Models\Customer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class CustomerRegistrationApiControllerV2 { public function store(Request $request): JsonResponse { $existedCustomer = Customer::where('phone', $request->input('phone.number'))->first(); if (!is_null($existedCustomer)) { throw new \InvalidArgumentException('Покупатель уже зарегистрирован!'); } $customer = Customer::create([ 'phone' => $request->input('phone.number'), 'phone_country' => $request->input('phone.country') ?? 'RU', 'password' => bcrypt($request->input('password')), 'name' => $request->input('first_name'), 'surname' => $request->input('last_name'), ]); dispatch(new SendRegistrationCode($customer)); return response()->json([ 'phone' => $customer->phone, ]); } }
Код снова приятно читать, не нужно ветвиться при добавлении новой версии – достаточно сделать ещё одну копию.
Но тут может возникнуть другая проблема. В новой версии могут добавиться новые необязательные поля, которые вряд ли будут нарушением обратной совместимости для предыдущей версии, поэтому их можно скопировать и в v1
.
Теперь вам нужно проверять, были ли портированы новые поля в старые версии, любые изменения в новой версии (по-хорошему) должны так же портироваться в предыдущую версию, чтобы сохранить единую логику работы.
Третий способ – использование интерфейсов
Именно этим способом я хочу поделиться.
Скорее всего вы знаете, что сервис контейнер Laravel позволяет связать интерфейс и имплементацию, после чего достаточно запрашивать нужный интерфейс, а не конкретный класс.
Знаете ли вы, что это работает и с запросами? Если у вас есть класс запроса (наследованный от Illuminate\Http\FormRequest
) с правилами валидации и авторизации и он реализует интерфейс, вы можете внедрить этот интерфейс в метод контроллера и Laravel выполнит проверку авторизации и валидацию данных точно так же, как если бы вы внедрили класс запроса.
Такая возможность позволяет нам сделать два разных запроса для каждой версии API, описать в них свои правила валидации и реализовать общий интерфейс с геттерами данных. Затем запросить этот интерфейс в метод контроллера и убрать все проверки версии (и, соответственно, не копировать контроллеры).
В дополнение к этому вы можете создать интерфейс трансформера ответа API и возвращать необходимую структуру из реализаций для конкретных версий.
<?php namespace App\Http\Controllers; use App\Interfaces\Requests\RegisterCustomerRequestInterface; use App\Interfaces\Transformers\CustomerRegisteredTransformerInterface; use App\Jobs\SendRegistrationCode; use App\Models\Customer; use Illuminate\Http\JsonResponse; class CustomerRegistrationApiController { public function store( RegisterCustomerRequestInterface $request, CustomerRegisteredTransformerInterface $transformer, ): JsonResponse { $existedCustomer = Customer::where('phone', $request->getPhoneNumber())->first(); if (!is_null($existedCustomer)) { throw new \InvalidArgumentException('Покупатель уже зарегистрирован!'); } $customer = Customer::create([ 'phone' => $request->getPhoneNumber(), 'phone_country' => $request->getPhoneCountry(), 'password' => bcrypt($request->getCustomerPassword()), 'name' => $request->getFirstName(), 'surname' => $request->getLastName(), ]); dispatch(new SendRegistrationCode($customer)); return response()->json($transformer->toArray($customer)); } }
С этим